package org.cainiao.oauth2.client.springboot.oauth2client;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.cainiao.oauth2.client.core.component.AjaxLoginUrlAuthenticationEntryPoint;
import org.cainiao.oauth2.client.core.component.ScopeAuthorityAuthorizationManager;
import org.cainiao.oauth2.client.core.component.requestcache.CustomRequestCache;
import org.cainiao.oauth2.client.core.filter.ForceHttpsPortAndSchemeFilter;
import org.cainiao.oauth2.client.core.properties.CNOAuth2ClientProperties;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.ResolvableType;
import org.springframework.http.MediaType;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.annotation.web.configurers.ExceptionHandlingConfigurer;
import org.springframework.security.config.annotation.web.configurers.oauth2.client.OAuth2LoginConfigurer;
import org.springframework.security.oauth2.client.registration.ClientRegistration;
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository;
import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver;
import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter;
import org.springframework.security.oauth2.core.AuthorizationGrantType;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.DelegatingAuthenticationEntryPoint;
import org.springframework.security.web.authentication.ui.DefaultLoginPageGeneratingFilter;
import org.springframework.security.web.context.SecurityContextHolderFilter;
import org.springframework.security.web.savedrequest.RequestCache;
import org.springframework.security.web.util.matcher.*;
import org.springframework.util.ReflectionUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.accept.ContentNegotiationStrategy;
import org.springframework.web.accept.HeaderContentNegotiationStrategy;
import org.springframework.web.servlet.mvc.method.annotation.RequestMappingHandlerMapping;

import java.lang.reflect.Field;
import java.util.*;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static org.cainiao.acl.core.component.AclUtil.customizeAuthorizeByAnnotation;
import static org.cainiao.oauth2.client.core.component.ScopeAuthorityAuthorizationManager.DYNAMIC_SCOPES_NAME;
import static org.cainiao.oauth2.client.core.component.ScopeAuthorityAuthorizationManager.SCOPE_PREFIX;
import static org.cainiao.oauth2.client.core.constant.Constant.COMMON_SCOPES;
import static org.springframework.security.config.Customizer.withDefaults;

/**
 * <br />
 * <p>
 * Author: Cai Niao(wdhlzd@163.com)<br />
 */
@Configuration(proxyBeanMethods = false)
@EnableConfigurationProperties(CNOAuth2ClientProperties.class)
public class CNAuthorizationCenterOAuth2WebSecurityConfiguration {

    @Bean
    RequestCache requestCache() {
        return new CustomRequestCache();
    }

    @Bean
    @ConditionalOnClass({SecurityFilterChain.class, HttpSecurity.class})
    @ConditionalOnMissingBean(name = "oauth2SecurityFilterChain")
    SecurityFilterChain oauth2SecurityFilterChain(HttpSecurity http, HttpServletRequest request,
                                                  RequestCache requestCache,
                                                  ClientRegistrationRepository clientRegistrationRepository,
                                                  RequestMappingHandlerMapping requestMappingHandlerMapping,
                                                  CNOAuth2ClientProperties properties) throws Exception {
        http
            // 前后端分离的情况，后端不渲染页面，无法在页面添加 CSRF Token，因此禁用
            .csrf(AbstractHttpConfigurer::disable)
            .authorizeHttpRequests(authorizationManagerRequestMatcherRegistry -> {
                /*
                 * 遍历 Spring MVC 的 HandlerMethods
                 * 根据入口方法上的 CnACL 注解，向 RequestMatcherDelegatingAuthorizationManager 的 mappings 注册 AuthorizationManager<RequestAuthorizationContext>
                 * TODO 每个 HandlerMethod 一个 RequestMatcherEntry，会导致每个请求经历时间复杂度为 n 的查找 AuthorizationManager 过程
                 *  由于无法贸然推断用户的需求，因此无法合并多个 CnACL 相同的 HandlerMethod
                 *  为了提高性能，可以采用缓存的方式来加速匹配，去掉 query 参数后，多数请求地址是重复的，但要特别注意路径参数造成缓存抖动的问题
                 *  看看能否从 HandlerMethod 中得到路径参数信息，或通过配置路径参数 URL 模式来优化缓存策略（参考 Spring MVC 匹配入口方法时的优化方法）
                 *
                 * 在需要 Scope 时，用自定义的 ScopeAuthorityAuthorizationManager
                 * 例如当接口需要 SCOPE_lark 权限时，会检查鉴权主体中的【授权服务器 access token】是否包含 lark Scope
                 * 如果没有，即便用户已经登录，也应该让用户以飞书登录的方式重新登录
                 * 在会话中存入 dynamicScopes = lark，以便 OAuth2 重定向解析器拼接授权服务器授权端点 URL 时加上 lark 请求参数
                 * 这样授权服务器就知道用 lark 三方登录的方式来登录
                 */
                customizeAuthorizeByAnnotation(requestMappingHandlerMapping, authorizationManagerRequestMatcherRegistry,
                    (authorizedUrl, cnACL) -> {
                        Set<String> scopeSet = Arrays.stream(cnACL.scopes())
                            .map(scope -> SCOPE_PREFIX + scope).collect(Collectors.toSet());
                        String[] authorities = Stream
                            .concat(Stream.of(cnACL.authorities()), Stream.of(cnACL.value())).toArray(String[]::new);
                        if (scopeSet.isEmpty()) {
                            /*
                             * 在没有 scope 时，常规处理
                             * 即：用户没登录则跳转登录，用户已登录但没有权限，则响应 403
                             */
                            if (authorities.length > 0) {
                                authorizedUrl.hasAnyAuthority(authorities);
                            } else {
                                authorizedUrl.authenticated();
                            }
                        } else {
                            authorizedUrl.access(
                                new ScopeAuthorityAuthorizationManager(scopeSet, Arrays.asList(authorities), request));
                        }
                    });
                // 没有权限要求的接口，允许匿名访问
                authorizationManagerRequestMatcherRegistry.anyRequest().permitAll();
            })
            .oauth2Login(oAuth2LoginConfigurer -> oAuth2LoginConfigurer
                .authorizationEndpoint(authorizationEndpointConfig -> configAuthorizationEndpoint(
                    authorizationEndpointConfig, clientRegistrationRepository, request, properties)))
            .oauth2Client(withDefaults())
            /*
             * 自定义 RequestCache，用于登录成功后的重放
             * 前后端分离，触发登录的请求都是 AJAX 请求，需要通过某种方式来得到正确的重放 URL
             */
            .requestCache(requestCacheConfigurer -> requestCacheConfigurer.requestCache(requestCache));
        http.exceptionHandling(exceptionHandlingConfigurer -> configAuthenticationEntryPoint(
            exceptionHandlingConfigurer, http, clientRegistrationRepository, properties));
        if (properties.isForceHttps()) {
            http.addFilterBefore(new ForceHttpsPortAndSchemeFilter(), SecurityContextHolderFilter.class);
        }
        return http.build();
    }

    /**
     * 设置鉴权入口，AuthenticationEntryPoint<br />
     * 重定向逻辑是：只有一个 ClientRegistration，则重定向到其对应的 OAuth2 授权端点<br />
     * 否则重定向到登录页，以便让用户选择用什么方式登录<br />
     * <p/>
     * 另外，由于前后端分离，AJAX 请求返回 302 重定向存在问题<br />
     * 在后续重定向到授权服务器时，浏览器会继续以 AJAX 方式重定向，这会导致跨域 AJAX 而失败<br />
     * 302 状态码会被浏览器拦截，axios 这样的 JS HTTP 客户端无法拦截并进行特殊处理<br />
     * 因此通过自定义 AjaxLoginUrlAuthenticationEntryPoint 将响应码改为 401，由前端 JS 负责页面重定向
     *
     * @param exceptionHandlingConfigurer  ExceptionHandlingConfigurer<HttpSecurity>
     * @param http                         HttpSecurity
     * @param clientRegistrationRepository ClientRegistrationRepository
     * @param properties                   CNOAuth2ClientProperties
     */
    private void configAuthenticationEntryPoint(ExceptionHandlingConfigurer<HttpSecurity> exceptionHandlingConfigurer,
                                                HttpSecurity http,
                                                ClientRegistrationRepository clientRegistrationRepository,
                                                CNOAuth2ClientProperties properties) {
        Map<String, String> loginUrlToClientName = getLoginLinks(clientRegistrationRepository, properties);
        // TODO，loginPage 应该改为可配置的
        String loginPage = "/login";
        if (loginUrlToClientName.size() == 1) {
            /*
             * loginUrlToClientName 大小为 1 表示当前应用只注册了一个 OAuth2 客户端，即只有一种三方登录模式
             * 此时重定向到对应的授权服务器进行登录
             *
             * 如果这里配置为相对路径，那么在运行时，会从当前请求中获取协议、域名端口等信息
             * 当在本地开发，并且前端开发人员启动了前端代理服务器的时候，这存在问题
             * 因为这种情况下，为了保持 sessionID，所有浏览器 URL 都应该是本地地址
             * 但经过前端代理，实际在后端看到的协议域名是前端代理转发的协议域名
             * 因此，这里提供一个从属性配置获取 loginFormUrlPrefix 的扩展点，以便在本地开发时可以配置为绝度路径的协议和域名部分
             * 这样在运行时就不会动态通过当前请求来拼接了
             */
            String loginFormUrlPrefix = properties.getLoginFormUrlPrefix();

            RequestMatcher loginPageMatcher = new AntPathRequestMatcher(loginPage);
            RequestMatcher faviconMatcher = new AntPathRequestMatcher("/favicon.ico");
            RequestMatcher defaultEntryPointMatcher = getAuthenticationEntryPointMatcher(http);
            RequestMatcher defaultLoginPageMatcher = new AndRequestMatcher(
                new OrRequestMatcher(loginPageMatcher, faviconMatcher), defaultEntryPointMatcher);
            RequestMatcher notXRequestedWith = new NegatedRequestMatcher(
                new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
            RequestMatcher formLoginNotEnabled = getFormLoginNotEnabledRequestMatcher(http);
            LinkedHashMap<RequestMatcher, AuthenticationEntryPoint> entryPoints = new LinkedHashMap<>();
            String providerLoginPage = loginUrlToClientName.keySet().iterator().next();
            entryPoints.put(new AndRequestMatcher(notXRequestedWith, new NegatedRequestMatcher(defaultLoginPageMatcher),
                formLoginNotEnabled), new AjaxLoginUrlAuthenticationEntryPoint(String.format("%s%s",
                StringUtils.hasText(loginFormUrlPrefix) ? loginFormUrlPrefix : "", providerLoginPage)));
            DelegatingAuthenticationEntryPoint loginEntryPoint = new DelegatingAuthenticationEntryPoint(entryPoints);
            loginEntryPoint.setDefaultEntryPoint(new AjaxLoginUrlAuthenticationEntryPoint(loginPage));
            exceptionHandlingConfigurer.authenticationEntryPoint(loginEntryPoint);
        } else {
            /*
             * 无论 loginUrlToClientName 大小为 0 还是大于一
             * 这里都无法确定用户会选择哪个三方登录方式进行登录，因此重定向到登录页，让用户在登录页自己选择登录方式
             */
            exceptionHandlingConfigurer.authenticationEntryPoint(new AjaxLoginUrlAuthenticationEntryPoint(loginPage));
        }
    }

    protected final RequestMatcher getAuthenticationEntryPointMatcher(HttpSecurity http) {
        ContentNegotiationStrategy contentNegotiationStrategy = http.getSharedObject(ContentNegotiationStrategy.class);
        if (contentNegotiationStrategy == null) {
            contentNegotiationStrategy = new HeaderContentNegotiationStrategy();
        }
        MediaTypeRequestMatcher mediaMatcher = new MediaTypeRequestMatcher(contentNegotiationStrategy,
            MediaType.APPLICATION_XHTML_XML, new MediaType("image", "*"), MediaType.TEXT_HTML,
            MediaType.TEXT_PLAIN);
        mediaMatcher.setIgnoredMediaTypes(Collections.singleton(MediaType.ALL));
        RequestMatcher notXRequestedWith = new NegatedRequestMatcher(
            new RequestHeaderRequestMatcher("X-Requested-With", "XMLHttpRequest"));
        return new AndRequestMatcher(Arrays.asList(notXRequestedWith, mediaMatcher));
    }

    private RequestMatcher getFormLoginNotEnabledRequestMatcher(HttpSecurity http) {
        DefaultLoginPageGeneratingFilter defaultLoginPageGeneratingFilter = http
            .getSharedObject(DefaultLoginPageGeneratingFilter.class);
        Field formLoginEnabledField = (defaultLoginPageGeneratingFilter != null)
            ? ReflectionUtils.findField(DefaultLoginPageGeneratingFilter.class, "formLoginEnabled") : null;
        if (formLoginEnabledField != null) {
            ReflectionUtils.makeAccessible(formLoginEnabledField);
            return (request) -> Boolean.FALSE
                .equals(ReflectionUtils.getField(formLoginEnabledField, defaultLoginPageGeneratingFilter));
        }
        return AnyRequestMatcher.INSTANCE;
    }

    private Map<String, String> getLoginLinks(ClientRegistrationRepository clientRegistrationRepository,
                                              CNOAuth2ClientProperties properties) {
        Iterable<ClientRegistration> clientRegistrations = null;
        ResolvableType type = ResolvableType.forInstance(clientRegistrationRepository).as(Iterable.class);
        if (type != ResolvableType.NONE && ClientRegistration.class.isAssignableFrom(type.resolveGenerics()[0])) {
            clientRegistrations = (Iterable<ClientRegistration>) clientRegistrationRepository;
        }
        if (clientRegistrations == null) {
            return Collections.emptyMap();
        }
        Map<String, String> loginUrlToClientName = new HashMap<>();
        clientRegistrations.forEach((registration) -> {
            if (AuthorizationGrantType.AUTHORIZATION_CODE.equals(registration.getAuthorizationGrantType())) {
                loginUrlToClientName.put(getAuthorizationRequestBaseUriWithCgi(properties)
                    + "/" + registration.getRegistrationId(), registration.getClientName());
            }
        });
        return loginUrlToClientName;
    }

    /**
     * 为 OAuth2AuthorizationRequestRedirectFilter 配置 OAuth2 授权请求重定向地址解析器<br />
     * 这个 Filter 负责通过 registrationId 从数据库获取 ClientRegistration<br />
     * 然后解析并重定向到 OAuth2 授权服务器的授权端点
     *
     * @param authorizationEndpointConfig  OAuth2LoginConfigurer<HttpSecurity>.AuthorizationEndpointConfig
     * @param clientRegistrationRepository ClientRegistrationRepository
     * @param request                      HttpServletRequest
     * @param properties                   CNOAuth2ClientProperties
     */
    private void configAuthorizationEndpoint(
        OAuth2LoginConfigurer<HttpSecurity>.AuthorizationEndpointConfig authorizationEndpointConfig,
        ClientRegistrationRepository clientRegistrationRepository,
        HttpServletRequest request, CNOAuth2ClientProperties properties) {

        String baseUri = getAuthorizationRequestBaseUri(properties);
        DefaultOAuth2AuthorizationRequestResolver oAuth2AuthorizationRequestResolver =
            new DefaultOAuth2AuthorizationRequestResolver(clientRegistrationRepository, baseUri);
        oAuth2AuthorizationRequestResolver.setAuthorizationRequestCustomizer(oAuth2AuthorizationRequestBuilder -> {
            /*
             * dynamicScopes 表示本次请求用户授予 OAuth2 客户端哪些额外的 Scope
             * 授权服务器会根据这些额外的 Scope 决定让用户用什么方式登录授权服务器
             * 请求哪些额外的 Scope 取决于 OAuth2 客户端的需求
             * 例如 OAuth2 客户端的某个接口，需要访问用户的飞书资源，需要通过授权中心的资源服务器端点访问
             * 这等价于在访问用户在授权服务器的【飞书】资源
             * 而授权服务器要访问用户在飞书服务器的资源，则需要授权服务器会话中有用【飞书】的 access token
             * 因此需要用户在授权服务器以【飞书】的方式登录
             * OAuth2 客户端接口需要什么能力，减去当前 OAuth2 客户端的【授权服务器 access token】已经拥有的 Scope，得到的是还缺失的 Scope
             * 也就是这里需要请求的 dynamicScopes
             * 只有在访问 OAuth2 客户端接口时的鉴权授权拦截器中才能获取到这些信息，而那时的请求与当前的请求不是同一个请求
             * 跨请求的数据需要通过 HTTP 会话传递，因此这里应该从会话中获取
             */
            HttpSession httpSession = request.getSession(false);
            Object dynamicScopesObject = httpSession == null ? null : httpSession.getAttribute(DYNAMIC_SCOPES_NAME);
            Object[] dynamicScopes = dynamicScopesObject instanceof Object[] ? (Object[]) dynamicScopesObject : null;
            int dynamicScopeLength;
            if (dynamicScopes != null && (dynamicScopeLength = dynamicScopes.length) > 0) {
                String[] scopes = new String[dynamicScopeLength];
                for (int i = 0; i < dynamicScopeLength; i++) {
                    Object obj = dynamicScopes[i];
                    scopes[i] = obj == null ? null : obj.toString();
                }
                oAuth2AuthorizationRequestBuilder.scope(Stream
                    .concat(COMMON_SCOPES.stream(), Arrays.stream(scopes)).toArray(String[]::new));
            } else {
                oAuth2AuthorizationRequestBuilder.scopes(COMMON_SCOPES);
            }
        });
        authorizationEndpointConfig.baseUri(baseUri).authorizationRequestResolver(oAuth2AuthorizationRequestResolver);
    }

    private String getAuthorizationRequestBaseUriWithCgi(CNOAuth2ClientProperties properties) {
        String cgiPrefix = properties.getCgiUriPrefix();
        return String.format("%s%s", StringUtils.hasText(cgiPrefix)
            ? cgiPrefix : "", getAuthorizationRequestBaseUri(properties));
    }

    /**
     * 获取 OAuth2 登录端点<br />
     * 当用户 Token 失效需要登录时，会被重定向到这个地址，这个地址会被 OAuth2AuthorizationRequestRedirectFilter 捕获<br />
     * 在 OAuth2AuthorizationRequestRedirectFilter 中通过 registrationId 解析并重定向到 OAuth2 授权服务器的授权端点<br />
     * 有两个地方会用到这个地址
     * <ol>
     *     <li>AuthenticationEntryPoint 发起登录时的重定向地址</li>
     *     <li>AntPathRequestMatcher 的 AntPathRequestMatcher，用于从 URL 中解析 registrationId</li>
     * </ol>
     *
     * @param properties CNOAuth2ClientProperties
     * @return OAuth2 登录端点
     */
    private String getAuthorizationRequestBaseUri(CNOAuth2ClientProperties properties) {
        String prefix = properties.getAuthorizationRequestBaseUriPrefix();
        return String.format("%s%s", StringUtils.hasText(prefix) ? prefix : "",
            OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI);
    }
}
